{# Chat macros: the transcript's one set of bubbles. A live turn and a history replay draw the same markup: ``user_bubble`` for what was asked, ``streaming_bubble`` while the answer arrives (the script fills its body), ``assistant_bubble`` for a stored, settled message (markdown, the tool trail, the footer). Every hook the scripts read is a data attribute or an id here, never framework scope, which is what lets one macro serve both a history render and the swap at the end of a live turn. #} {% from "components/macros/icons.html" import icon %} {% from "components/macros/layout.html" import avatar, copy_icon %} {# The chat's buttons: outlined and quiet, so the conversation stays louder than the controls. ``tone`` primary is teal; ``size`` sm or xs. #} {% macro button_classes(tone="default", size="sm") -%} {% set tones = {"default": "border-aegis-border text-aegis-muted hover:border-aegis-muted hover:text-aegis-text hover:bg-aegis-border/20", "primary": "btn-primary"} %} btn btn-outline {{ {"sm": "btn-sm", "xs": "btn-xs"}[size] }} {{ tones.get(tone, tones["default"]) }} {%- endmacro %} {# What a voice control is doing, drawn from its data-state (voice.js only sets it): the resting icon (the caller) while idle; recording, a pulsing dot (the mic only, ``record=True``); thinking, a spinner while audio is heard or fetched; speaking, an equalizer while it plays. The control carries ``group relative``; one set of visuals for the mic and every speaker. #} {% macro voice_states(size="w-4 h-4", record=False, work=False) %} {{ caller() }} {% if record %} {% endif %} {{ icon("spinner", "hidden group-data-[state=thinking]:block " ~ size ~ " animate-spin", 1.5, 'data-mic-visual="thinking"') }} {# ``work``: the agent pulling data mid-call - not the spinner, which is connecting or transcribing. #} {% if work %} {% endif %} {% endmacro %} {# A card under a reply, loaded in place by its own route. #} {% macro card_loader(url, what) %}
Loading {{ what }}…
{% endmacro %} {# Hear an answer: the stored message, said aloud. Same place and weight as the copy button; voice.js plays it, and plays it unasked when the turn began at the microphone. #} {% macro speak_button(url, extra="", form=False) %} {% endmacro %} {% macro user_bubble(text, attachments=None, attachment_names=None) %}
  • You {{ copy_icon(text, "text-aegis-muted/70 hover:text-aegis-text transition-colors") }}
    {{ text }} {% if attachments %}
    {% for image in attachments %} {% endfor %}
    {% elif attachment_names %}

    Attached: {{ attachment_names|join(", ") }}

    {% endif %}
  • {% endmacro %} {# The rows under an answer: one per tool call, each opening the run in the modal with what it actually did. #} {% set trail_classes = "mt-1 mb-3 space-y-1 text-xs font-mono text-aegis-muted" %} {% macro trail_rows(rows) %} {% if rows %} {% endif %} {% endmacro %} {# The answer's typography: what the markdown filter emits, styled once. #} {% set prose = "text-sm leading-relaxed text-aegis-text [&_p]:my-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:mt-5 [&_h1]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:mt-5 [&_h2]:mb-2 [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:my-0.5 [&_strong]:font-semibold [&_code]:font-mono [&_code]:text-xs [&_code]:text-aegis-teal [&_pre]:rounded [&_pre]:bg-aegis-bg [&_pre]:border [&_pre]:border-aegis-border [&_pre]:p-3 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre_code]:text-aegis-text [&_table]:my-2 [&_th]:text-left [&_th]:font-medium [&_th]:pr-4 [&_td]:pr-4 [&_td]:py-0.5 [&_a]:text-aegis-teal [&_blockquote]:border-l-2 [&_blockquote]:border-aegis-border [&_blockquote]:pl-3 [&_blockquote]:text-aegis-muted" %} {# The assistant's name over an answer, in both bubbles. #} {# The agent's name over a reply. With ``url`` it opens that agent's settings in the side drawer; inside the trail's summary the click must not also fold the trail, hence the preventDefault. #} {% macro assistant_name(assistant, url=None) %}{% if url %}{% else %}{{ assistant }}{% endif %}{% endmacro %} {# An assistant's row in the thread: the settled bubble, the one a turn streams into, and a card drawn mid-call all sit on it. #} {% macro assistant_row() %}class="pr-24" data-role="assistant"{% endmacro %} {% macro assistant_bubble(message, assistant) %}
  • {# The tool trail folds under the name: a caret shows on hover over the bubble (and stays while open), so the audit is a click away without sitting in the transcript. #} {% if message.trail %}
    {{ assistant_name(assistant, message.agent_url) }} {{ message.trail|length }} {{ "step" if message.trail|length == 1 else "steps" }} {{ icon("chevron-down", "w-3 h-3 opacity-0 group-hover/bubble:opacity-100 group-open/trail:opacity-100 group-open/trail:rotate-180 transition", 2) }} {{ trail_rows(message.trail) }}
    {% else %}
    {{ assistant_name(assistant, message.agent_url) }}
    {% endif %}
    {{ message.content|markdown }}
    {# The card the reply drew, then the approval cards its tools proposed, loaded fresh so a change shows its state now, not when it was filed. #} {% for url in message.cards %}{{ card_loader(url, "the chart") }}{% endfor %} {% for url in message.changes %}{{ card_loader(url, "the change") }}{% endfor %} {% if message.cut_off %}
    {{ message.cut_off }} {% if message.agent_url %}{% endif %}
    {% endif %}
    {% if message.model_icon %}{{ avatar(message.footer, message.model_icon, size="xs") }}{% endif %} {% if message.footer %}{{ message.footer }}{% endif %} {{ copy_icon(message.content, "opacity-0 group-hover/bubble:opacity-100") }} {% if message.speech %}{{ speak_button(message.speech, "opacity-0 group-hover/bubble:opacity-100 focus:opacity-100 aria-pressed:opacity-100") }}{% endif %}
  • {% endmacro %} {# The bubble a live turn streams into. The script reads ``data-text`` (what to send) and fills ``data-body`` and ``data-trail``; when the stream completes it swaps this whole element for ``assistant_bubble`` rendered from the stored message. #} {% macro streaming_bubble(text, conversation_id, assistant) %}
  • {{ assistant_name(assistant) }}
  • {% endmacro %} {# The chat's two verbs, wherever the surface shows: a fresh thread, and the history dialog. #} {% macro chat_toolbar(path) %}
    {% endmacro %} {# The composer's chips are one button: a label that opens its dialog. ``mark`` names the attribute that says what the chip shows (for scripts and tests), ``value`` its value if any. #} {% macro composer_chip(id, label, path, title, mark, value=None, oob=False) %} {% endmacro %} {# The composer's model chip: the active model's id, opening the picker. Re-rendered out of band by a pick. #} {% macro model_chip(chip, path, oob=False) %}{{ composer_chip("chat-model", chip.label, path, "Switch the active model", "data-model", chip.model, oob) }}{% endmacro %} {# The composer's voice chip: the active voice profile's name, opening the voice dialog. Same place and weight as the model chip; it loads itself, and a switch sends it back out of band. #} {% macro voice_chip(active, path, oob=False) %}{{ composer_chip("chat-voice", active.name if active else "Voice", path, "Change the voice", "data-voice-name", oob=oob) }}{% endmacro %} {# One stored message as its bubble: the transcript and the settle swap draw messages through this, so the role switch has one home. #} {% macro bubble(message, assistant) %} {% if message.role == "user" %}{{ user_bubble(message.content, attachments=message.attachments) }}{% else %}{{ assistant_bubble(message, assistant) }}{% endif %} {% endmacro %} {# The composer's conversation id: in the composer itself, and out of band when a load replaces the thread. #} {% macro conversation_field(conversation_id, oob=False) %} {% endmacro %} {# The attributes for "load ``url`` into the thread", replacing it: a fresh thread, a conversation picked from history. #} {% macro hx_thread(url) %}hx-get="{{ url }}" hx-target="#chat-thread" hx-swap="innerHTML"{% endmacro %}